/**************************************************************************\
    Copyright Microsoft Corporation. All Rights Reserved.
\**************************************************************************/

namespace C4F.DevKit.Contacts
{
    using C4F.DevKit.Contacts.Standard;
    using System;
    using System.Collections.Generic;
    using System.Collections.Specialized;
    using System.IO;
    using System.Threading;
    using System.Windows.Threading;

    sealed internal class ContactWatcher : IDisposable
    {
        private struct ContactInfo
        {
            public string Id;
            public DateTime LastWrite;
            // Consider: public Guid HashValue;
        }

        #region Callback Delegate Declarations
        delegate void _FileChanged(string path);
        delegate void _FileRenamed(string oldPath, string newPath);
        #endregion

        #region Fields
        // The manager that this is bound to.
        private readonly ContactManager _manager;
        // Listen for changes to the file system under the contact manager's RootDirectory.
        private FileSystemWatcher _fileWatch;
        // Mapping of file names to ContactIds.
        private readonly Dictionary<string, ContactInfo> _knownContacts;
        // Sometimes an update can't be processed because the file has immediately been moved.
        // Cache the need to process the update until we can open the file.
        // We'll only ever hold one of these at a time, but this prevents the general case of
        // an update/move from appearing as a remove/add.
        private string _pendingUpdateFile;
        #endregion

        #region Private Utilities

        private void _ReprocessPendingUpdate()
        {
            if (!string.IsNullOrEmpty(_pendingUpdateFile))
            {
                _OnFileChanged(_pendingUpdateFile);
            }
        }

        private void _TryGetContactInfo(string path, ref ContactInfo newInfo, ref ContactCollectionChangeType action)
        {
            try
            {
                // We just have a file with this extension, it's not necessarily valid.
                // If we can't get a contact from it then don't raise a notification.
                using (Contact contact = new Contact(path))
                {
                    newInfo.Id = contact.Id;
                    newInfo.LastWrite = File.GetLastWriteTime(path);
                }

                _knownContacts[path] = newInfo;
            }
            // Couldn't load the file.  Whatever...
            catch (FileNotFoundException)
            {
                // If we couldn't load the file and this was going to be treated as an add, then just do nothing.
                if (ContactCollectionChangeType.Added == action)
                {
                    action = ContactCollectionChangeType.NoChange;
                }
                else if (ContactCollectionChangeType.Updated == action)
                {
                    // File might have gotten moved.  Wait for the next event and see if it's available.
                    _pendingUpdateFile = path;
                    action = ContactCollectionChangeType.NoChange;
                }
                else
                {
                    action = ContactCollectionChangeType.Removed;
                    _knownContacts.Remove(path);
                }
            }
            catch (InvalidDataException)
            {
                // If we couldn't load the file and this was going to be treated as an add, then just do nothing.
                action = (ContactCollectionChangeType.Added == action)
                    ? ContactCollectionChangeType.NoChange
                    : ContactCollectionChangeType.Removed;
                _knownContacts.Remove(path);
            }
        }


        /// <summary>
        /// Generic function to deal with changed file events of types Created and Changed.
        /// </summary>
        /// <param name="path">The full path of the changed file.</param>
        public void _OnFileChanged(string path)
        {
            Assert.IsTrue(_manager.CheckAccess());

            if (path != _pendingUpdateFile)
            {
                _ReprocessPendingUpdate();
            }

            ContactInfo oldInfo = default(ContactInfo);
            ContactInfo newInfo = default(ContactInfo);
            ContactCollectionChangeType action = _knownContacts.TryGetValue(path, out oldInfo)
                ? ContactCollectionChangeType.Updated
                : ContactCollectionChangeType.Added;

            for (int i = 0; i < 5; ++i)
            {
                try
                {
                    _TryGetContactInfo(path, ref newInfo, ref action);
                    break;
                }
                catch (UnauthorizedAccessException e)
                {
                    if (i == 5)
                    {
                        Assert.Fail(e.Message);
                        action = ContactCollectionChangeType.NoChange;
                    }
                    System.Windows.Forms.Application.DoEvents();
                }
            }

            switch (action)
            {
                case ContactCollectionChangeType.NoChange:
                    break;
                case ContactCollectionChangeType.Updated:
                    // If the write time hasn't changed since the last notification then swallow this redundant notification
                    if (newInfo.LastWrite != oldInfo.LastWrite)
                    {
                        _manager._NotifyCollectionChanged(_manager, new ContactCollectionChangedEventArgs(action, newInfo.Id, oldInfo.Id));
                    }
                    break;
                case ContactCollectionChangeType.Added:
                    _manager._NotifyCollectionChanged(_manager, new ContactCollectionChangedEventArgs(action, newInfo.Id));
                    break;
                case ContactCollectionChangeType.Removed:
                    _manager._NotifyCollectionChanged(_manager, new ContactCollectionChangedEventArgs(action, oldInfo.Id));
                    break;
                default:
                    Assert.Fail();
                    break;
            }

            // Clear the pending update if we just processed it.
            if (_pendingUpdateFile == path && action != ContactCollectionChangeType.NoChange)
            {
                _pendingUpdateFile = null;
            }
        }

        /// <summary>
        /// Generic function to deal with changed file events of type Deleted.
        /// </summary>
        /// <param name="path">The full path of the deleted file.</param>
        private void _OnFileDeleted(string path)
        {
            Assert.IsTrue(_manager.CheckAccess());

            _ReprocessPendingUpdate();

            // If the file wasn't a known contact, then nothing to do.
            // Otherwise, remove it from the list and send the notification.
            ContactInfo oldInfo;
            if (_knownContacts.TryGetValue(path, out oldInfo))
            {
                _knownContacts.Remove(path);
                _manager._NotifyCollectionChanged(
                    _manager,
                    new ContactCollectionChangedEventArgs(
                        ContactCollectionChangeType.Removed,
                        oldInfo.Id));
            }
        }

        private void _OnFileRenamed(string oldPath, string newPath)
        {
            Assert.IsTrue(_manager.CheckAccess());

            ContactInfo oldInfo = default(ContactInfo);
            ContactInfo newInfo = default(ContactInfo);
            if (_knownContacts.TryGetValue(oldPath, out oldInfo))
            {
                _knownContacts.Remove(oldPath);

                // Update the Id at the new location
                try
                {
                    // Make sure we can still get the contact.  If we can't then remove it.
                    // If we can't get a contact from it then don't raise a notification.
                    using (Contact contact = new Contact(newPath))
                    {
                        newInfo.Id = contact.Id;
                        newInfo.LastWrite = File.GetLastWriteTime(newPath);
                    }

                    _knownContacts[newPath] = newInfo;
                }
                // Couldn't load the file.  Whatever...
                catch (FileNotFoundException)
                {
                    // If we couldn't load the file, treat this as a delete.  But it's weird that this happened...
                    _manager._NotifyCollectionChanged(_manager, new ContactCollectionChangedEventArgs(ContactCollectionChangeType.Removed, oldInfo.Id));
                    return;
                }

                _manager._NotifyCollectionChanged(_manager, new ContactCollectionChangedEventArgs(ContactCollectionChangeType.Moved, newInfo.Id, oldInfo.Id));

                // Did we miss an update because this file was moved?
                // If so, reprocess it.
                Assert.IsNotNull(oldPath);
                if (_pendingUpdateFile == oldPath)
                {
                    newInfo = _knownContacts[newPath];
                    // Modify the write time so we won't ignore the change.
                    // This is kindof fragile, but tests should catch if the comparison method changes.
                    newInfo.LastWrite = newInfo.LastWrite.AddMilliseconds(-100);
                    _knownContacts[newPath] = newInfo;
                    _pendingUpdateFile = newPath;
                    _ReprocessPendingUpdate();
                }
            }
        }

        private void _PopulateKnownContacts()
        {
            Assert.IsNotNull(_manager);
            Assert.IsTrue(_manager.CheckAccess());
            Assert.IsNotNull(_knownContacts);
            Assert.AreEqual(0, _knownContacts.Count);

            // Go through all contacts associated with the manager and make the mapping of file paths to the ContactIds. 
            foreach (Contact contact in _manager.GetContactCollection())
            {
                ContactInfo info = default(ContactInfo);
                info.Id = contact.Id;
                info.LastWrite = File.GetLastWriteTime(contact.Path);
                _knownContacts.Add(contact.Path, info);
                contact.Dispose();
            }
        }
        #endregion

        #region Constructors

        public ContactWatcher(ContactManager manager)
        {
            Assert.IsNotNull(manager);

            _manager = manager;

            // This is why creating this object is expensive:
            // In order to be able to give accurate change notifications we need to know all the contacts that are present at the beginning.
            _knownContacts = new Dictionary<string, ContactInfo>();
            // It's not required that this is created on the same thread as the manager, so go through the dispatcher if not.
            _manager.Dispatcher.Invoke(DispatcherPriority.Send, (ThreadStart)_PopulateKnownContacts);

            _fileWatch = new FileSystemWatcher(_manager.RootDirectory, "*.contact");
            _fileWatch.IncludeSubdirectories = true;
            _fileWatch.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Attributes;

            // There's some extra indirection required here because the manager requires thread affinity.
            _fileWatch.Changed +=
                delegate(object sender, FileSystemEventArgs e)
                {
                    _manager.Dispatcher.Invoke(DispatcherPriority.Normal, (_FileChanged)_OnFileChanged, e.FullPath);
                };
            _fileWatch.Created +=
                delegate(object sender, FileSystemEventArgs e)
                {
                    _manager.Dispatcher.Invoke(DispatcherPriority.Normal, (_FileChanged)_OnFileChanged, e.FullPath);
                };
            _fileWatch.Deleted +=
                delegate(object sender, FileSystemEventArgs e)
                {
                    _manager.Dispatcher.Invoke(DispatcherPriority.Normal, (_FileChanged)_OnFileDeleted, e.FullPath);
                };
            _fileWatch.Renamed +=
                delegate(object sender, RenamedEventArgs e)
                {
                    _manager.Dispatcher.Invoke(DispatcherPriority.Normal, (_FileRenamed)_OnFileRenamed, e.OldFullPath, e.FullPath);
                };
            _fileWatch.EnableRaisingEvents = true;
        }

        #endregion

        #region IDisposable Pattern

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        ~ContactWatcher()
        {
            Dispose(false);
        }

        private void Dispose(bool disposing)
        {
            // Explicitly don't dispose of the manager.  It's just a backpointer.
            if (disposing)
            {
                Utility.SafeDispose(ref _fileWatch);
            }
        }

        #endregion
    }
}
